Skip to content

fix(oauth): revoke replaced sessions and preserve token metadata - #1147

Merged
EhabY merged 4 commits into
mainfrom
fix/oauth-scope-session-lifecycle
Oct 13, 2026
Merged

EhabY merged 4 commits into
mainfrom
fix/oauth-scope-session-lifecycle

Conversation

@EhabY

@EhabY EhabY commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Follow-up to #1138's token-cleanup comment: clean up replaced OAuth sessions without forcing an immediate re-login when required scopes change. Closes #1140: this PR covers items 1 and 3, and #1128 covers item 2.

  • Keep existing expiry behavior: missing required scopes stop refresh but keep the stored access token, so normal 401 recovery applies after expiry. The eligibility check is renamed to canRefreshOAuthSession.
  • Revoke replaced sessions: a successful sign-in saves first, then revokes the overwritten OAuth pair in the background (best effort). Failed or canceled sign-ins, same-token reuse, and stored-session adoption revoke nothing.
  • Refresh and revoke as the issuing registration: each OAuth session records the registration (client ID and secret) its tokens were issued to, so refresh and revocation keep working when another window re-registers. Sessions saved before this change fall back to the stored registration, which login now saves only after it succeeds, so a failed login can't replace it.
  • Preserve OAuth metadata on reuse: reusing the same token on the same origin keeps its refresh credentials, scopes, and registration. If a refresh rotates the session while its stored token is checked, the rotated session wins.
  • Keep credentials out of logs: every logged argument goes through one redaction walk, for both the output channel and the connection log buffer. It redacts credential fields (token, refresh_token, client_secret, ...) with string values in plain objects and errors, and reduces an Axios error at any depth, including an error's cause, to its request line, status, and the server's reply. The error's config and socket hold the session token, OAuth tokens, and client secret. The walk reads only data properties, never throws, and skips entries the channel drops.
  • Share OAuth plumbing: revokeOAuthTokens in src/oauth/revocation.ts serves logout and replacement. withOAuthMetadata in src/oauth/metadataClient.ts creates the client and fetches metadata for revocation and token refresh.
  • Scopes: request user:update_personal, which coder start needs to refresh expired external-auth links. No narrower scope exists, so it also allows profile and Git SSH key changes; a comment next to the scope says so. Inbox permissions stay optional.
  • Dynamic client registration disabled (OAuth follow-ups: DCR off by default on 2.38, unnoticed nightly scope failures, CLI inheriting CODER_SESSION_TOKEN #1140, item 1): OAuth is offered only when discovery advertises registration_endpoint. From Coder 2.37, discovery omits it while dynamic_client_registration_enabled is off (the default), and the register endpoint returns 403; 2.25 to 2.36 always advertise it and always allow registration. Otherwise sign-in falls back to a session token: when DCR is off, a notice says so and a warning is logged; when the server has no OAuth discovery (404 or network error), only an info line is logged. The changelog notes that OAuth becomes the default sign-in method and links the OAuth2 provider docs.
  • CLI token from the environment (OAuth follow-ups: DCR off by default on 2.38, unnoticed nightly scope failures, CLI inheriting CODER_SESSION_TOKEN #1140, item 3): CLI processes the extension starts (start, update, speedtest, netcheck, support bundle, ping, login, logout, and the app-command terminal with strictEnv) get process.env without CODER_SESSION_TOKEN, matched the way the CLI reads it: any letter case, with or without the HOMEBREW_ prefix. AGENTS.md adds this to the CLI execution rules. The SSH ProxyCommand runs in an environment Remote-SSH sets, so it starts with an empty --token= instead; the CLI applies flags over the environment, and a user's own --token in coder.globalFlags comes later and still wins. No other CODER_* variable changes which session is used: --url and --use-keyring already override theirs, and CODER_AGENT_TOKEN is read only by agent commands.

Out of scope: startup or remote admission gates, per-action permission maps, and a background-refresh redesign. Before expiry, users can still see feature-specific permission errors; those don't prompt for sign-in.

Change size

Diff against the merge base; counts include moved code and renames.

Area Added Removed Net
Production (src/**) 450 248 +202
Tests (test/**) 551 63 +488
Changelog and AGENTS.md 20 3 +17
Total 1021 314 +707

Validation

Four commits on top of main.

  • pnpm test: 191 files, 3,055 passed, 6 skipped.
  • pnpm typecheck, pnpm lint, and pnpm format:check: passed.
  • Integration tests were not rerun for this revision.
  • Regression tests cover stored and provided token reuse; manual, provided, and OAuth replacement; refresh rotation during a stored-token login; save-before-revoke ordering; failed and canceled sign-in; a failed login keeping the stored registration; discovery without registration_endpoint disabling OAuth with a notice, and failed discovery falling back without one; the host session token (any case, HOMEBREW_ prefix) dropped from every spawned CLI's environment, including the app-command terminal, while storeToken passes its own; the empty --token= in the ProxyCommand flags with a user --token overriding it; refresh and revocation as the recorded registration, with the stored registration as fallback; an Axios error, alone or nested in an error's cause, whose logged form omits its token, client secret, and session header; credential fields redacted in any logged object, on both the output channel and the buffer; and redaction that keeps references when nothing changes, never runs getters, never throws, and keeps a redacted error's stack.

Known limitations

  • A background refresh already in flight when an OAuth sign-in replaces the session can race the revocation. Fixing that needs refresh coordination, which is out of scope here.
  • A user whose deployment turned DCR off but who already holds a registration is offered session-token sign-in, though the existing registration would still work.

Generated by Coder Agents on behalf of @EhabY.

@EhabY
EhabY force-pushed the fix/oauth-scope-session-lifecycle branch from e8d23c6 to 8125427 Compare October 9, 2026 23:05
@EhabY EhabY changed the title fix(oauth): preserve and revoke sessions across scope upgrades fix(oauth): revoke replaced sessions and preserve token metadata Oct 9, 2026
@EhabY
EhabY force-pushed the fix/oauth-scope-session-lifecycle branch 8 times, most recently from c01e269 to 183b2bc Compare October 11, 2026 14:30
@matifali

Copy link
Copy Markdown
Member

The changelog for this PR also notes that OAuth will be the default auth method in the future. And add a link to the Coder docs.

@EhabY
EhabY requested a review from code-asher October 12, 2026 19:47
Comment thread src/cli/cliCredentialManager.ts Outdated
Comment thread src/cli/cliCredentialManager.ts Outdated
signal: options?.signal,
});
// `login` reads the token from the environment, so drop the empty flag.
const flags = cli.flags.filter((flag) => flag !== EMPTY_TOKEN_FLAG);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that we are setting --token= to prevent the CLI from using the environment variable, but this feels like using a side effect as a janky workaround when we could directly unset/set the environment variable instead as necessary.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Spawned CLI processes now get process.env without CODER_SESSION_TOKEN (any case, plus serpent's HOMEBREW_ fallback). --token= stays only in the SSH ProxyCommand and terminal.sendText, whose env we can't set. No other CODER_* var affects which session is used.

Comment thread src/login/loginCoordinator.ts
Comment thread src/login/loginCoordinator.ts
Comment thread src/login/loginCoordinator.ts Outdated
Comment thread src/oauth/revocation.ts Outdated
Comment thread src/oauth/tokens.ts Outdated
@EhabY
EhabY force-pushed the fix/oauth-scope-session-lifecycle branch from fdfe25b to a20f0af Compare October 13, 2026 10:43
@EhabY
EhabY requested a review from code-asher October 13, 2026 10:47
@EhabY
EhabY force-pushed the fix/oauth-scope-session-lifecycle branch 2 times, most recently from 5c10f0f to 2c347e5 Compare October 13, 2026 11:38

@Lisa-Fiander Lisa-Fiander left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EhabY added 4 commits October 13, 2026 15:53
Clean up replaced OAuth sessions without forcing a re-login when the
required scopes change.

- Insufficient scopes prevent refresh but keep the stored access token, so
  normal 401 recovery applies after expiry. Rename the check to
  `canRefreshOAuthSession`.
- Save a successful replacement first, then revoke the overwritten OAuth
  pair in the background with the client registration captured before
  login. Failed or canceled logins, same-token reuse, and stored-session
  adoption revoke nothing.
- Keep refresh credentials and scopes when the same token is reused on the
  same origin, instead of turning it into a manual-token session. If a
  refresh rotates the session while its stored token is checked, keep the
  rotated session.
- Share revocation with logout through `withOAuthMetadata`, which token
  refresh also uses, and request `user:update_personal` to refresh expired
  external-auth links.
- If the server refuses dynamic client registration (off by default from
  Coder 2.38), name the setting and offer session-token sign-in.
- Start every CLI invocation's global flags with an empty `--token=`, so
  `CODER_SESSION_TOKEN` in the extension host (in any case) no longer
  overrides the stored session. This covers the SSH ProxyCommand and
  terminals too. `coder login --use-token-as-session` omits it, since it
  reads the token from the environment, and gets an environment without
  the host's case variants of the variable.

Closes #1140
…ient

- Log Axios errors as their request line and the server's reply. Their
  config and socket hold the session token, OAuth tokens, and client
  secret, which the buffered connection logs replayed into the output
  channel.
- Record the client that issued each OAuth session, and refresh and
  revoke as that client, so another window re-registering the client no
  longer breaks either. Sessions without a recorded client use the stored
  registration.
- Explain why the extension requests `user:update_personal`.
- In the changelog, note that OAuth becomes the default sign-in method
  and link the OAuth2 provider docs.
- Shorten comments.
- Spawned CLI processes, including the app-command terminal (with
  `strictEnv`), get `process.env` without `CODER_SESSION_TOKEN`, matched
  as the CLI reads it: any letter case, with or without the `HOMEBREW_`
  prefix. Only the SSH ProxyCommand keeps an inline `--token=`, since
  Remote-SSH sets its environment.
- Save a client registration only after the OAuth login succeeds, so a
  failed login keeps the registration a stored session was issued to.
- Rename the recorded OAuth client to `registration` to tell the
  registered app apart from the HTTP client.
- Offer OAuth only when discovery advertises `registration_endpoint`,
  which Coder omits while dynamic client registration is off (the
  default from 2.37), instead of failing after the user picks OAuth.
  A notice explains the session-token fallback, and both unavailable
  cases are logged.
The logger summarized only a top-level Axios error. Every logged
argument now goes through one redaction walk, for both the output
channel and the flight-recorder buffer. It redacts credential fields
with string values in plain objects and errors, and summarizes Axios
errors at any depth, including error causes. The walk reads only data
properties, never throws, and skips entries the channel drops.
@EhabY
EhabY force-pushed the fix/oauth-scope-session-lifecycle branch from f987fd6 to 1059ab7 Compare October 13, 2026 13:00
@EhabY
EhabY merged commit d22be3f into main Oct 13, 2026
12 of 14 checks passed
@EhabY
EhabY deleted the fix/oauth-scope-session-lifecycle branch October 13, 2026 13:04
@EhabY EhabY mentioned this pull request Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

OAuth follow-ups: DCR off by default on 2.38, unnoticed nightly scope failures, CLI inheriting CODER_SESSION_TOKEN

4 participants